iT邦幫忙

1

經典小畫家白板

  • 分享至 

  • xImage
  •  

經典小畫家白板:
https://ithelp.ithome.com.tw/upload/images/20260904/201840085gzspHAQ9e.png
程式碼:

<!DOCTYPE html>
<html lang="zh-TW" class="h-full bg-slate-100">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>經典小畫家白板</title>
    <!-- Tailwind CSS CDN -->
    <script src="https://cdn.tailwindcss.com"></script>
    <!-- FontAwesome Icons -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <style>
        /* 繪圖畫布預設為純白背景 */
        #drawCanvas {
            cursor: crosshair;
            background-color: #ffffff;
            touch-action: none;
        }
    </style>
</head>
<body class="h-full flex flex-col overflow-hidden font-sans select-none">

    <!-- 頂部功能區 (Ribbon UI 樣式) -->
    <header class="bg-slate-200 border-b border-slate-300 px-4 py-2 flex flex-col shadow-sm z-50">
        <!-- 上方標題與檔案動作列 -->
        <div class="flex items-center justify-between pb-2 border-b border-slate-300/60 mb-2">
            <div class="flex items-center gap-2">
                <i class="fa-solid fa-paintbrush text-xl text-blue-600"></i>
                <h1 class="text-base font-bold text-slate-800 tracking-tight">經典小畫家白板</h1>
            </div>
            
            <div class="flex items-center gap-2">
                <!-- 檔案上傳按鈕 (支援圖片上傳與編輯) -->
                <label for="imageInput" class="cursor-pointer flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 rounded text-xs font-medium shadow-sm transition">
                    <i class="fa-solid fa-folder-open text-blue-600"></i>
                    <span>開啟圖片</span>
                </label>
                <input type="file" id="imageInput" accept="image/*" class="hidden">

                <button id="clearBtn" class="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 hover:bg-rose-50 hover:text-rose-600 text-slate-700 rounded text-xs font-medium shadow-sm transition">
                    <i class="fa-solid fa-file-circle-plus text-rose-500"></i>
                    <span>新增白板</span>
                </button>

                <button id="saveBtn" class="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs font-medium shadow-sm transition">
                    <i class="fa-solid fa-floppy-disk"></i>
                    <span>儲存</span>
                </button>
            </div>
        </div>

        <!-- 仿 Ribbon 工具列面板 (精簡為鉛筆、橡皮擦、大小、色彩調色盤) -->
        <div class="flex items-center gap-6 overflow-x-auto py-1 text-xs">
            
            <!-- 影像群組 (復原/重做) -->
            <div class="flex flex-col items-center border-r border-slate-300 pr-6">
                <div class="flex gap-1 mb-1">
                    <button id="undoBtn" disabled title="復原" class="p-2 bg-white border border-slate-300 hover:bg-slate-100 rounded disabled:opacity-40"><i class="fa-solid fa-rotate-left"></i></button>
                    <button id="redoBtn" disabled title="重做" class="p-2 bg-white border border-slate-300 hover:bg-slate-100 rounded disabled:opacity-40"><i class="fa-solid fa-rotate-right"></i></button>
                </div>
                <span class="text-[11px] text-slate-600 font-medium">歷程</span>
            </div>

            <!-- 工具群組 (僅保留鉛筆與橡皮擦) -->
            <div class="flex flex-col items-center border-r border-slate-300 pr-6">
                <div class="flex gap-2 mb-1">
                    <button id="pencilTool" title="鉛筆/畫筆" class="tool-btn flex flex-col items-center justify-center w-10 h-10 bg-blue-100 border border-blue-400 text-blue-700 rounded shadow-sm">
                        <i class="fa-solid fa-pencil text-sm"></i>
                        <span class="text-[9px] mt-0.5">鉛筆</span>
                    </button>
                    <button id="eraserTool" title="橡皮擦" class="tool-btn flex flex-col items-center justify-center w-10 h-10 bg-white border border-slate-300 hover:bg-slate-100 text-slate-700 rounded shadow-sm">
                        <i class="fa-solid fa-eraser text-sm"></i>
                        <span class="text-[9px] mt-0.5">橡皮擦</span>
                    </button>
                </div>
                <span class="text-[11px] text-slate-600 font-medium">工具</span>
            </div>

            <!-- 大小粗細群組 -->
            <div class="flex flex-col items-center border-r border-slate-300 pr-6">
                <div class="flex flex-col justify-center h-full mb-1">
                    <label for="brushSize" class="text-[10px] text-slate-600 mb-0.5">筆刷粗細: <span id="sizeText" class="font-bold">3</span>px</label>
                    <input type="range" id="brushSize" min="1" max="40" value="3" class="w-28 accent-blue-600">
                </div>
                <span class="text-[11px] text-slate-600 font-medium">大小</span>
            </div>

            <!-- 色彩調色盤群組 (含當選取外框發亮提示) -->
            <div class="flex items-center gap-4">
                <!-- 顏色方格網 -->
                <div class="grid grid-cols-10 gap-1.5" id="paletteGrid">
                    <!-- 顏色由 JavaScript 動態產生 -->
                </div>

                <!-- 自訂編輯色彩 -->
                <div class="flex flex-col items-center pl-3 border-l border-slate-300">
                    <label for="customColor" class="cursor-pointer flex flex-col items-center">
                        <div class="w-8 h-8 rounded bg-gradient-to-tr from-rose-500 via-emerald-500 to-blue-500 border border-slate-400 shadow-sm flex items-center justify-center text-white text-xs">
                            <i class="fa-solid fa-palette"></i>
                        </div>
                        <span class="text-[10px] text-slate-600 mt-0.5">編輯色彩</span>
                    </label>
                    <input type="color" id="customColor" value="#000000" class="hidden">
                </div>
            </div>

        </div>
    </header>

    <!-- 繪圖白板主區域 -->
    <main class="flex-1 bg-slate-400 p-6 flex justify-center items-center overflow-auto">
        <div class="bg-white shadow-2xl border border-slate-500 relative flex items-center justify-center" id="canvasWrapper">
            <canvas id="drawCanvas" width="900" height="550"></canvas>
        </div>
    </main>

    <!-- 底部狀態列 -->
    <footer class="bg-slate-200 border-t border-slate-300 px-4 py-1 text-xs text-slate-600 flex justify-between items-center z-50">
        <div id="statusInfo" class="flex items-center gap-4">
            <span>畫布大小: 900 x 550 像素</span>
            <span id="coordInfo">游標: 0, 0</span>
        </div>
        <div>
            <span>經典小畫家白板 &copy; 2026</span>
        </div>
    </footer>

    <!-- JavaScript 繪圖邏輯 -->
    <script>
        const canvas = document.getElementById('drawCanvas');
        const ctx = canvas.getContext('2d', { willReadFrequently: true });
        const canvasWrapper = document.getElementById('canvasWrapper');
        const imageInput = document.getElementById('imageInput');
        
        // 控制元件
        const pencilToolBtn = document.getElementById('pencilTool');
        const eraserToolBtn = document.getElementById('eraserTool');
        const brushSizeInput = document.getElementById('brushSize');
        const sizeText = document.getElementById('sizeText');
        const paletteGrid = document.getElementById('paletteGrid');
        const customColorInput = document.getElementById('customColor');
        const clearBtn = document.getElementById('clearBtn');
        const undoBtn = document.getElementById('undoBtn');
        const redoBtn = document.getElementById('redoBtn');
        const saveBtn = document.getElementById('saveBtn');
        const coordInfo = document.getElementById('coordInfo');

        // 狀態變數
        let currentTool = 'pencil'; // 'pencil', 'eraser'
        let currentColor = '#000000';
        let currentSize = 3;
        let isDrawing = false;
        let startX = 0, startY = 0;
        let history = [];
        let historyStep = -1;

        // 初始化白板背景為白色
        function initCanvasBackground() {
            ctx.fillStyle = '#ffffff';
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            saveState();
        }

        // 狀態管理 (復原/重做)
        function saveState() {
            historyStep++;
            if (historyStep < history.length) {
                history.length = historyStep;
            }
            history.push(canvas.toDataURL());
            updateHistoryButtons();
        }

        function updateHistoryButtons() {
            undoBtn.disabled = historyStep <= 0;
            redoBtn.disabled = historyStep >= history.length - 1;
        }

        // 重新編排的色譜顏色順序
        const colors = [
            '#000000', '#ed1c24', '#ff7f27', '#ffc90e', '#fff200', '#22b14c', '#00a2e8', '#3f48cc', '#a349a4', '#7f7f7f',
            '#ffffff', '#ffaec9', '#ffc89d', '#fae7b5', '#b5e61d', '#99d9ea', '#7092be', '#c8bfe7', '#b97a57', '#c3c3c3'
        ];

        let colorButtons = [];

        colors.forEach((hex) => {
            const btn = document.createElement('button');
            btn.className = 'w-6 h-6 rounded border border-slate-400 shadow-sm transition hover:scale-110 relative';
            btn.style.backgroundColor = hex;
            
            btn.addEventListener('click', () => {
                currentColor = hex;
                updateColorSelection(btn);
            });
            
            paletteGrid.appendChild(btn);
            colorButtons.push({ btn, hex });
        });

        // 更新調色盤選取外框提示
        function updateColorSelection(selectedBtn) {
            colorButtons.forEach(item => {
                item.btn.classList.remove('ring-2', 'ring-blue-600', 'scale-110', 'z-10');
                item.btn.style.borderColor = '#94a3b8';
            });
            if (selectedBtn) {
                selectedBtn.classList.add('ring-2', 'ring-blue-600', 'scale-110', 'z-10');
                selectedBtn.style.borderColor = '#2563eb';
            }
        }

        // 預設選中第一個黑色
        if (colorButtons.length > 0) {
            updateColorSelection(colorButtons[0].btn);
        }

        customColorInput.addEventListener('input', (e) => {
            currentColor = e.target.value;
            updateColorSelection(null);
        });

        // 工具切換
        const allToolBtns = [pencilToolBtn, eraserToolBtn];
        function setActiveTool(selectedBtn, toolName) {
            allToolBtns.forEach(btn => {
                btn.classList.remove('bg-blue-100', 'border-blue-400', 'text-blue-700');
                btn.classList.add('bg-white', 'border-slate-300', 'text-slate-700');
            });
            selectedBtn.classList.remove('bg-white', 'border-slate-300', 'text-slate-700');
            selectedBtn.classList.add('bg-blue-100', 'border-blue-400', 'text-blue-700');
            currentTool = toolName;
            canvas.style.cursor = toolName === 'eraser' ? 'cell' : 'crosshair';
        }

        pencilToolBtn.addEventListener('click', () => setActiveTool(pencilToolBtn, 'pencil'));
        eraserToolBtn.addEventListener('click', () => setActiveTool(eraserToolBtn, 'eraser'));

        brushSizeInput.addEventListener('input', (e) => {
            currentSize = parseInt(e.target.value);
            sizeText.textContent = currentSize;
        });

        // 取得畫布相對座標
        function getMousePos(e) {
            const rect = canvas.getBoundingClientRect();
            return {
                x: Math.floor(e.clientX - rect.left),
                y: Math.floor(e.clientY - rect.top)
            };
        }

        // 繪圖事件監聽
        canvas.addEventListener('mousedown', (e) => {
            const pos = getMousePos(e);
            startX = pos.x;
            startY = pos.y;

            isDrawing = true;
            ctx.beginPath();
            ctx.moveTo(startX, startY);
        });

        canvas.addEventListener('mousemove', (e) => {
            const pos = getMousePos(e);
            coordInfo.textContent = `游標: ${pos.x}, ${pos.y}`;

            if (!isDrawing) return;

            ctx.strokeStyle = currentTool === 'eraser' ? '#ffffff' : currentColor;
            ctx.lineWidth = currentSize;
            ctx.lineCap = 'round';
            ctx.lineJoin = 'round';
            ctx.lineTo(pos.x, pos.y);
            ctx.stroke();
        });

        canvas.addEventListener('mouseup', () => {
            if (isDrawing) {
                isDrawing = false;
                saveState();
            }
        });

        canvas.addEventListener('mouseleave', () => {
            if (isDrawing) {
                isDrawing = false;
                saveState();
            }
        });

        // 圖片上傳功能
        imageInput.addEventListener('change', (e) => {
            const file = e.target.files[0];
            if (!file) return;

            const reader = new FileReader();
            reader.onload = function(event) {
                const img = new Image();
                img.onload = function() {
                    ctx.fillStyle = '#ffffff';
                    ctx.fillRect(0, 0, canvas.width, canvas.height);
                    
                    let w = img.width;
                    let h = img.height;
                    if (w > canvas.width || h > canvas.height) {
                        const ratio = Math.min(canvas.width / w, canvas.height / h);
                        w *= ratio;
                        h *= ratio;
                    }
                    const x = (canvas.width - w) / 2;
                    const y = (canvas.height - h) / 2;
                    ctx.drawImage(img, x, y, w, h);
                    saveState();
                }
                img.src = event.target.result;
            }
            reader.readAsDataURL(file);
        });

        // 新增白板
        clearBtn.addEventListener('click', () => {
            ctx.fillStyle = '#ffffff';
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            saveState();
        });

        // 復原與重做
        undoBtn.addEventListener('click', () => {
            if (historyStep > 0) {
                historyStep--;
                let img = new Image();
                img.src = history[historyStep];
                img.onload = () => {
                    ctx.clearRect(0, 0, canvas.width, canvas.height);
                    ctx.drawImage(img, 0, 0);
                    updateHistoryButtons();
                }
            }
        });

        redoBtn.addEventListener('click', () => {
            if (historyStep < history.length - 1) {
                historyStep++;
                let img = new Image();
                img.src = history[historyStep];
                img.onload = () => {
                    ctx.clearRect(0, 0, canvas.width, canvas.height);
                    ctx.drawImage(img, 0, 0);
                    updateHistoryButtons();
                }
            }
        });

        // 儲存圖片
        saveBtn.addEventListener('click', () => {
            const link = document.createElement('a');
            link.download = 'paint-drawing.png';
            link.href = canvas.toDataURL();
            link.click();
        });

        // 初始化
        initCanvasBackground();
    </script>
</body>
</html>

*提醒邦友,使用第三方服務/API 時,請務必評估資安風險與隱私保護
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

1
dscwferp
iT邦高手 1 級 ‧ 2026-09-04 23:10:16

這個很好用喔! 大力推薦!
感謝您的分享
用AI 加了:

  1. 可以打字<-不用手寫
  2. 物件+圖層<-可以移動位置
  3. 複製貼上<-不用重做
  4. 剪貼簿複製貼上<-跟其他軟體互動
    一起分享!!
    共同創作!
    希望大家喜歡!
<!DOCTYPE html>
<html lang="zh-TW" class="h-full bg-slate-100">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>經典小畫家白板</title>
    <!-- Tailwind CSS CDN -->
    <script src="https://cdn.tailwindcss.com"></script>
    <!-- FontAwesome Icons -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <style>
        /* 繪圖畫布預設為純白背景 */
        #drawCanvas {
            cursor: crosshair;
            background-color: #ffffff;
            touch-action: none;
        }
        /* 打字功能:浮動文字輸入框 */
        #textOverlay {
            position: absolute;
            display: none;
            flex-direction: column;
            border: 1.5px dashed #2563eb;
            background: rgba(255,255,255,0.9);
            min-width: 120px;
            max-width: 90%;
            z-index: 20;
        }
        #textDragBar {
            cursor: move;
            background: #2563eb;
            color: #fff;
            font-size: 11px;
            padding: 2px 6px;
            display: flex;
            justify-content: space-between;
            align-items: center;
            user-select: none;
            touch-action: none;
        }
        #textInput {
            outline: none;
            border: none;
            background: transparent;
            resize: both;
            overflow: hidden;
            min-width: 120px;
            min-height: 1.5em;
            padding: 4px 6px;
            line-height: 1.4;
            white-space: pre-wrap;
        }
        #textConfirmBar {
            display: flex;
            gap: 4px;
            padding: 2px 4px 4px 4px;
        }
        #layerList::-webkit-scrollbar { width: 6px; }
        #layerList::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
    </style>
</head>
<body class="h-full flex flex-col overflow-hidden font-sans select-none">

    <!-- 頂部功能區 (Ribbon UI 樣式) -->
    <header class="bg-slate-200 border-b border-slate-300 px-4 py-2 flex flex-col shadow-sm z-50">
        <!-- 上方標題與檔案動作列 -->
        <div class="flex items-center justify-between pb-2 border-b border-slate-300/60 mb-2">
            <div class="flex items-center gap-2">
                <i class="fa-solid fa-paintbrush text-xl text-blue-600"></i>
                <h1 class="text-base font-bold text-slate-800 tracking-tight">經典小畫家白板</h1>
            </div>

            <div class="flex items-center gap-2">
                <!-- 檔案上傳按鈕 (支援圖片上傳與編輯) -->
                <label for="imageInput" class="cursor-pointer flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 rounded text-xs font-medium shadow-sm transition">
                    <i class="fa-solid fa-folder-open text-blue-600"></i>
                    <span>開啟圖片</span>
                </label>
                <input type="file" id="imageInput" accept="image/*" class="hidden">

                <button id="clearBtn" class="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-300 hover:bg-rose-50 hover:text-rose-600 text-slate-700 rounded text-xs font-medium shadow-sm transition">
                    <i class="fa-solid fa-file-circle-plus text-rose-500"></i>
                    <span>新增白板</span>
                </button>

                <button id="saveBtn" class="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs font-medium shadow-sm transition">
                    <i class="fa-solid fa-floppy-disk"></i>
                    <span>儲存</span>
                </button>
            </div>
        </div>

        <!-- 仿 Ribbon 工具列面板 -->
        <div class="flex items-center gap-6 overflow-x-auto py-1 text-xs">

            <!-- 影像群組 (復原/重做) -->
            <div class="flex flex-col items-center border-r border-slate-300 pr-6">
                <div class="flex gap-1 mb-1">
                    <button id="undoBtn" disabled title="復原" class="p-2 bg-white border border-slate-300 hover:bg-slate-100 rounded disabled:opacity-40"><i class="fa-solid fa-rotate-left"></i></button>
                    <button id="redoBtn" disabled title="重做" class="p-2 bg-white border border-slate-300 hover:bg-slate-100 rounded disabled:opacity-40"><i class="fa-solid fa-rotate-right"></i></button>
                </div>
                <span class="text-[11px] text-slate-600 font-medium">歷程</span>
            </div>

            <!-- 工具群組 -->
            <div class="flex flex-col items-center border-r border-slate-300 pr-6">
                <div class="flex gap-2 mb-1">
                    <button id="pencilTool" title="鉛筆/畫筆" class="tool-btn flex flex-col items-center justify-center w-10 h-10 bg-blue-100 border border-blue-400 text-blue-700 rounded shadow-sm">
                        <i class="fa-solid fa-pencil text-sm"></i>
                        <span class="text-[9px] mt-0.5">鉛筆</span>
                    </button>
                    <button id="eraserTool" title="橡皮擦" class="tool-btn flex flex-col items-center justify-center w-10 h-10 bg-white border border-slate-300 hover:bg-slate-100 text-slate-700 rounded shadow-sm">
                        <i class="fa-solid fa-eraser text-sm"></i>
                        <span class="text-[9px] mt-0.5">橡皮擦</span>
                    </button>
                    <button id="textTool" title="文字/打字 (T)" class="tool-btn flex flex-col items-center justify-center w-10 h-10 bg-white border border-slate-300 hover:bg-slate-100 text-slate-700 rounded shadow-sm">
                        <i class="fa-solid fa-font text-sm"></i>
                        <span class="text-[9px] mt-0.5">文字</span>
                    </button>
                    <button id="selectTool" title="選取/移動 (V)" class="tool-btn flex flex-col items-center justify-center w-10 h-10 bg-white border border-slate-300 hover:bg-slate-100 text-slate-700 rounded shadow-sm">
                        <i class="fa-solid fa-arrow-pointer text-sm"></i>
                        <span class="text-[9px] mt-0.5">選取</span>
                    </button>
                </div>
                <span class="text-[11px] text-slate-600 font-medium">工具</span>
            </div>

            <!-- 選取物件操作群組 -->
            <div class="flex flex-col items-center border-r border-slate-300 pr-6">
                <div class="flex gap-1 mb-1">
                    <button id="delObjBtn" title="刪除選取 (Del)" class="p-2 bg-white border border-slate-300 hover:bg-rose-50 hover:text-rose-600 rounded disabled:opacity-40" disabled><i class="fa-solid fa-trash"></i></button>
                    <button id="copyObjBtn" title="複製選取 (Ctrl+C)" class="p-2 bg-white border border-slate-300 hover:bg-slate-100 rounded disabled:opacity-40" disabled><i class="fa-solid fa-copy"></i></button>
                    <button id="pasteObjBtn" title="貼上 (Ctrl+V)" class="p-2 bg-white border border-slate-300 hover:bg-slate-100 rounded disabled:opacity-40" disabled><i class="fa-solid fa-paste"></i></button>
                    <button id="dupObjBtn" title="再製一份 (Ctrl+D)" class="p-2 bg-white border border-slate-300 hover:bg-slate-100 rounded disabled:opacity-40" disabled><i class="fa-solid fa-clone"></i></button>
                    <button id="sysCopyBtn" title="物件複製到系統剪貼簿" class="p-2 bg-white border border-slate-300 hover:bg-slate-100 rounded disabled:opacity-40" disabled><i class="fa-solid fa-file-export"></i></button>
                    <button id="sysPasteBtn" title="剪貼簿貼上成物件 (Ctrl+V)" class="p-2 bg-white border border-slate-300 hover:bg-slate-100 rounded"><i class="fa-solid fa-file-import"></i></button>
                    <button id="forwardBtn" title="上移一層" class="p-2 bg-white border border-slate-300 hover:bg-slate-100 rounded disabled:opacity-40" disabled><i class="fa-solid fa-arrow-up"></i></button>
                    <button id="backwardBtn" title="下移一層" class="p-2 bg-white border border-slate-300 hover:bg-slate-100 rounded disabled:opacity-40" disabled><i class="fa-solid fa-arrow-down"></i></button>
                    <button id="frontBtn" title="置頂" class="p-2 bg-white border border-slate-300 hover:bg-slate-100 rounded disabled:opacity-40" disabled><i class="fa-solid fa-angles-up"></i></button>
                    <button id="backBtn" title="置底" class="p-2 bg-white border border-slate-300 hover:bg-slate-100 rounded disabled:opacity-40" disabled><i class="fa-solid fa-angles-down"></i></button>
                </div>
                <span class="text-[11px] text-slate-600 font-medium">層級</span>
            </div>

            <!-- 大小粗細群組 -->
            <div class="flex flex-col items-center border-r border-slate-300 pr-6">
                <div class="flex flex-col justify-center h-full mb-1 gap-1">
                    <label for="brushSize" class="text-[10px] text-slate-600 mb-0.5">筆刷粗細: <span id="sizeText" class="font-bold">3</span>px</label>
                    <input type="range" id="brushSize" min="1" max="40" value="3" class="w-28 accent-blue-600">
                    <label for="fontSize" class="text-[10px] text-slate-600 mb-0.5">文字大小: <span id="fontSizeText" class="font-bold">28</span>px</label>
                    <input type="range" id="fontSize" min="12" max="120" value="28" class="w-28 accent-emerald-600">
                </div>
                <span class="text-[11px] text-slate-600 font-medium">大小</span>
            </div>

            <!-- 畫布尺寸群組 -->
            <div class="flex flex-col items-center border-r border-slate-300 pr-6">
                <div class="flex flex-col justify-center mb-1 gap-1">
                    <div class="flex items-center gap-1">
                        <label for="canvasW" class="text-[10px] text-slate-600">寬</label>
                        <input type="number" id="canvasW" value="900" min="200" max="3000" step="10" class="w-16 px-1 py-0.5 text-[11px] border border-slate-300 rounded bg-white outline-none focus:border-blue-500">
                        <span class="text-[10px] text-slate-400">×</span>
                        <label for="canvasH" class="text-[10px] text-slate-600">高</label>
                        <input type="number" id="canvasH" value="550" min="200" max="3000" step="10" class="w-16 px-1 py-0.5 text-[11px] border border-slate-300 rounded bg-white outline-none focus:border-blue-500">
                        <button id="canvasApplyBtn" title="套用畫布大小" class="px-2 py-0.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-[11px]">套用</button>
                    </div>
                    <div class="flex items-center gap-1">
                        <button data-canvas-preset="900x550" title="預設 900x550" class="canvas-preset px-1.5 py-0.5 bg-white border border-slate-300 hover:bg-slate-100 rounded text-[10px]">預設</button>
                        <button data-canvas-preset="1280x720" title="寬版 1280x720" class="canvas-preset px-1.5 py-0.5 bg-white border border-slate-300 hover:bg-slate-100 rounded text-[10px]">寬版</button>
                        <button data-canvas-preset="720x960" title="直式 720x960" class="canvas-preset px-1.5 py-0.5 bg-white border border-slate-300 hover:bg-slate-100 rounded text-[10px]">直式</button>
                        <button data-canvas-preset="1920x1080" title="1080p 1920x1080" class="canvas-preset px-1.5 py-0.5 bg-white border border-slate-300 hover:bg-slate-100 rounded text-[10px]">1080p</button>
                    </div>
                </div>
                <span class="text-[11px] text-slate-600 font-medium">畫布</span>
            </div>

            <!-- 色彩調色盤群組 (含當選取外框發亮提示) -->
            <div class="flex items-center gap-4">
                <!-- 顏色方格網 -->
                <div class="grid grid-cols-10 gap-1.5" id="paletteGrid">
                    <!-- 顏色由 JavaScript 動態產生 -->
                </div>

                <!-- 自訂編輯色彩 -->
                <div class="flex flex-col items-center pl-3 border-l border-slate-300">
                    <label for="customColor" class="cursor-pointer flex flex-col items-center">
                        <div class="w-8 h-8 rounded bg-gradient-to-tr from-rose-500 via-emerald-500 to-blue-500 border border-slate-400 shadow-sm flex items-center justify-center text-white text-xs">
                            <i class="fa-solid fa-palette"></i>
                        </div>
                        <span class="text-[10px] text-slate-600 mt-0.5">編輯色彩</span>
                    </label>
                    <input type="color" id="customColor" value="#000000" class="hidden">
                </div>
            </div>

        </div>
    </header>

    <!-- 繪圖白板主區域(含圖層面板) -->
    <main class="flex-1 bg-slate-400 p-4 flex gap-4 justify-center items-start overflow-auto">
        <div class="bg-white shadow-2xl border border-slate-500 relative flex items-center justify-center shrink-0" id="canvasWrapper">
            <canvas id="drawCanvas" width="900" height="550"></canvas>
            <!-- 打字功能浮動輸入框 -->
            <div id="textOverlay">
                <div id="textDragBar">
                    <span><i class="fa-solid fa-up-down-left-right mr-1"></i>拖曳移動</span>
                    <span class="opacity-70">Enter 換行 · Ctrl+Enter 完成</span>
                </div>
                <textarea id="textInput" rows="2" cols="20" placeholder="在此輸入文字..."></textarea>
                <div id="textConfirmBar">
                    <button id="textOkBtn" class="px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded text-[11px]"><i class="fa-solid fa-check mr-1"></i>完成</button>
                    <button id="textCancelBtn" class="px-2 py-1 bg-white border border-slate-300 hover:bg-slate-100 rounded text-[11px]">取消(Esc)</button>
                </div>
            </div>
        </div>
        <!-- 圖層面板 -->
        <aside class="w-60 shrink-0 bg-slate-100 border border-slate-500 shadow-2xl rounded overflow-hidden flex flex-col max-h-[550px]">
            <div class="bg-slate-200 px-3 py-2 border-b border-slate-300 flex items-center justify-between">
                <span class="text-xs font-bold text-slate-700"><i class="fa-solid fa-layer-group mr-1 text-blue-600"></i>圖層 <span id="layerCount" class="font-normal text-slate-500"></span></span>
                <span class="text-[10px] text-slate-500">上層在前</span>
            </div>
            <div id="layerList" class="flex-1 overflow-y-auto p-1.5 space-y-1 text-xs min-h-[100px]"></div>
            <div class="px-2 py-1.5 border-t border-slate-300 text-[10px] text-slate-500 leading-relaxed">
                選取工具(V)點物件可選取,拖曳移動,Del 刪除。<br>Ctrl+C 複製,Ctrl+V 貼上(含系統剪貼簿),Ctrl+D 再製。<br>右上 <i class="fa-solid fa-file-export"></i> 輸出到系統,<i class="fa-solid fa-file-import"></i> 從系統貼入。<br>點清單也可選取。
            </div>
        </aside>
    </main>

    <!-- 底部狀態列 -->
    <footer class="bg-slate-200 border-t border-slate-300 px-4 py-1 text-xs text-slate-600 flex justify-between items-center z-50">
        <div id="statusInfo" class="flex items-center gap-4">
            <span id="canvasSizeInfo">畫布大小: 900 x 550 像素</span>
            <span id="coordInfo">游標: 0, 0</span>
            <span id="selInfo" class="font-medium text-blue-700"></span>
        </div>
        <div>
            <span>經典小畫家白板 &copy; 2026</span>
        </div>
    </footer>

    <!-- JavaScript 繪圖邏輯(物件式圖層架構) -->
    <script>
        const canvas = document.getElementById('drawCanvas');
        const ctx = canvas.getContext('2d', { willReadFrequently: true });
        const canvasWrapper = document.getElementById('canvasWrapper');
        const imageInput = document.getElementById('imageInput');

        // 控制元件
        const pencilToolBtn = document.getElementById('pencilTool');
        const eraserToolBtn = document.getElementById('eraserTool');
        const textToolBtn = document.getElementById('textTool');
        const selectToolBtn = document.getElementById('selectTool');
        const brushSizeInput = document.getElementById('brushSize');
        const sizeText = document.getElementById('sizeText');
        const fontSizeInput = document.getElementById('fontSize');
        const fontSizeText = document.getElementById('fontSizeText');
        const textOverlay = document.getElementById('textOverlay');
        const textDragBar = document.getElementById('textDragBar');
        const textInput = document.getElementById('textInput');
        const textOkBtn = document.getElementById('textOkBtn');
        const textCancelBtn = document.getElementById('textCancelBtn');
        const paletteGrid = document.getElementById('paletteGrid');
        const customColorInput = document.getElementById('customColor');
        const clearBtn = document.getElementById('clearBtn');
        const undoBtn = document.getElementById('undoBtn');
        const redoBtn = document.getElementById('redoBtn');
        const saveBtn = document.getElementById('saveBtn');
        const coordInfo = document.getElementById('coordInfo');
        const canvasSizeInfo = document.getElementById('canvasSizeInfo');
        const canvasWInput = document.getElementById('canvasW');
        const canvasHInput = document.getElementById('canvasH');
        const canvasApplyBtn = document.getElementById('canvasApplyBtn');
        const selInfo = document.getElementById('selInfo');
        const layerList = document.getElementById('layerList');
        const layerCount = document.getElementById('layerCount');
        const delObjBtn = document.getElementById('delObjBtn');
        const copyObjBtn = document.getElementById('copyObjBtn');
        const pasteObjBtn = document.getElementById('pasteObjBtn');
        const sysCopyBtn = document.getElementById('sysCopyBtn');
        const sysPasteBtn = document.getElementById('sysPasteBtn');
        const dupObjBtn = document.getElementById('dupObjBtn');
        const forwardBtn = document.getElementById('forwardBtn');
        const backwardBtn = document.getElementById('backwardBtn');
        const frontBtn = document.getElementById('frontBtn');
        const backBtn = document.getElementById('backBtn');

        // ===== 物件式狀態 =====
        // obj: {id,type:'stroke'|'text'|'image', ...}
        // stroke: {points:[{x,y}], color, size, eraser:bool}
        // text: {x,y,text,color,fontSize}
        // image: {x,y,w,h,src}
        let objects = [];
        let nextId = 1;
        let selectedId = null;
        let currentTool = 'pencil'; // 'pencil','eraser','text','select'
        let currentColor = '#000000';
        let currentSize = 3;
        let currentFontSize = 28;
        let currentStroke = null; // 繪製中的筆劃
        let isDrawing = false;
        let selectDrag = null; // 選取後拖曳移動
        let textPos = { x: 0, y: 0 };
        let history = [];
        let historyStep = -1;
        const imageCache = {};

        function getImage(src) {
            if (!imageCache[src]) {
                const im = new Image();
                im.src = src;
                im.onload = () => redrawAll();
                imageCache[src] = im;
            }
            return imageCache[src];
        }

        // ===== 歷程(物件快照,含畫布尺寸) =====
        function snapshot() { return JSON.stringify({ objects, nextId, w: canvas.width, h: canvas.height }); }
        function restore(json) {
            try {
                const d = JSON.parse(json);
                objects = d.objects || [];
                nextId = d.nextId || 1;
                if (Number.isInteger(d.w) && Number.isInteger(d.h)) applyCanvasSize(d.w, d.h, true);
                objects.forEach(o => { if (o.type === 'image') getImage(o.src); });
                if (selectedId != null && !objects.find(o => o.id === selectedId)) selectedId = null;
            } catch (e) { /* 忽略損毀快照 */ }
        }
        function saveState() {
            historyStep++;
            if (historyStep < history.length) history.length = historyStep;
            history.push(snapshot());
            if (history.length > 100) { history.shift(); historyStep--; }
            updateHistoryButtons();
        }
        function updateHistoryButtons() {
            undoBtn.disabled = historyStep <= 0;
            redoBtn.disabled = historyStep >= history.length - 1;
        }
        function initBoard() {
            objects = []; nextId = 1; selectedId = null;
            history = []; historyStep = -1;
            saveState();
            redrawAll();
        }

        // ===== 畫布尺寸 =====
        function updateCanvasSizeUI() {
            canvasWInput.value = canvas.width;
            canvasHInput.value = canvas.height;
            canvasSizeInfo.textContent = `畫布大小: ${canvas.width} x ${canvas.height} 像素`;
        }
        function applyCanvasSize(w, h, skipSave) {
            w = Math.max(200, Math.min(3000, Math.round(Number(w))));
            h = Math.max(200, Math.min(3000, Math.round(Number(h))));
            if (!Number.isFinite(w) || !Number.isFinite(h)) return false;
            if (w === canvas.width && h === canvas.height) { updateCanvasSizeUI(); return true; }
            commitText();
            canvas.width = w;
            canvas.height = h;
            updateCanvasSizeUI();
            if (!skipSave) { saveState(); }
            redrawAll();
            return true;
        }
        canvasApplyBtn.addEventListener('click', () => {
            if (!applyCanvasSize(canvasWInput.value, canvasHInput.value)) {
                flashStatus('畫布尺寸無效(寬高需為 200~3000)');
                updateCanvasSizeUI();
            }
        });
        [canvasWInput, canvasHInput].forEach(el => el.addEventListener('keydown', (e) => {
            e.stopPropagation();
            if (e.key === 'Enter') canvasApplyBtn.click();
        }));
        document.querySelectorAll('.canvas-preset').forEach(btn => btn.addEventListener('click', () => {
            const [w, h] = btn.dataset.canvasPreset.split('x').map(Number);
            applyCanvasSize(w, h);
        }));

        // ===== 繪製 =====
        function drawObject(c, o) {
            if (o.type === 'stroke') {
                if (!o.points || o.points.length === 0) return;
                c.save();
                c.strokeStyle = o.color;
                c.lineWidth = o.size;
                c.lineCap = 'round';
                c.lineJoin = 'round';
                c.beginPath();
                if (o.points.length === 1) {
                    const p = o.points[0];
                    c.fillStyle = o.color;
                    c.beginPath();
                    c.arc(p.x, p.y, o.size / 2, 0, Math.PI * 2);
                    c.fill();
                } else {
                    c.moveTo(o.points[0].x, o.points[0].y);
                    for (let i = 1; i < o.points.length; i++) c.lineTo(o.points[i].x, o.points[i].y);
                    c.stroke();
                }
                c.restore();
            } else if (o.type === 'text') {
                c.save();
                c.fillStyle = o.color;
                c.font = `${o.fontSize}px sans-serif`;
                c.textBaseline = 'top';
                const lines = String(o.text).split('\n');
                const lh = o.fontSize * 1.4;
                lines.forEach((line, i) => c.fillText(line, o.x, o.y + i * lh));
                c.restore();
            } else if (o.type === 'image') {
                const im = getImage(o.src);
                if (im.complete && im.naturalWidth) {
                    c.drawImage(im, o.x, o.y, o.w, o.h);
                }
            }
        }
        function getObjBounds(o) {
            if (o.type === 'text') {
                ctx.save();
                ctx.font = `${o.fontSize}px sans-serif`;
                const lines = String(o.text).split('\n');
                let w = 0;
                lines.forEach(l => { w = Math.max(w, ctx.measureText(l).width); });
                ctx.restore();
                return { x: o.x, y: o.y, w: Math.max(w, 10), h: lines.length * o.fontSize * 1.4 };
            } else if (o.type === 'image') {
                return { x: o.x, y: o.y, w: o.w, h: o.h };
            } else if (o.type === 'stroke') {
                if (!o.points.length) return null;
                let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
                o.points.forEach(p => {
                    minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
                    maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
                });
                const pad = o.size / 2 + 4;
                return { x: minX - pad, y: minY - pad, w: (maxX - minX) + pad * 2, h: (maxY - minY) + pad * 2 };
            }
            return null;
        }
        function redrawAll() {
            ctx.fillStyle = '#ffffff';
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            objects.forEach(o => drawObject(ctx, o));
            if (currentStroke && currentStroke.points.length) {
                drawObject(ctx, { type: 'stroke', points: currentStroke.points, color: currentStroke.color, size: currentStroke.size });
            }
            const sel = objects.find(o => o.id === selectedId);
            if (sel) {
                const b = getObjBounds(sel);
                if (b) {
                    ctx.save();
                    ctx.strokeStyle = '#2563eb';
                    ctx.lineWidth = 1.5;
                    ctx.setLineDash([6, 4]);
                    ctx.strokeRect(b.x, b.y, b.w, b.h);
                    ctx.setLineDash([]);
                    // 四角把手
                    ctx.fillStyle = '#2563eb';
                    [[b.x, b.y], [b.x + b.w, b.y], [b.x, b.y + b.h], [b.x + b.w, b.y + b.h]].forEach(([hx, hy]) => {
                        ctx.fillRect(hx - 3, hy - 3, 6, 6);
                    });
                    ctx.restore();
                }
            }
            renderLayerList();
            updateSelUI();
        }

        // ===== 命中測試 =====
        function distToSeg(px, py, ax, ay, bx, by) {
            const dx = bx - ax, dy = by - ay;
            const len2 = dx * dx + dy * dy;
            let t = len2 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0;
            t = Math.max(0, Math.min(1, t));
            const cx = ax + t * dx, cy = ay + t * dy;
            return Math.hypot(px - cx, py - cy);
        }
        function hitTest(x, y) {
            for (let i = objects.length - 1; i >= 0; i--) {
                const o = objects[i];
                if (o.type === 'image' || o.type === 'text') {
                    const b = getObjBounds(o);
                    if (b && x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) return o;
                } else if (o.type === 'stroke') {
                    const tol = o.size / 2 + 6;
                    if (o.points.length === 1) {
                        if (Math.hypot(x - o.points[0].x, y - o.points[0].y) <= tol) return o;
                    } else {
                        for (let k = 0; k < o.points.length - 1; k++) {
                            if (distToSeg(x, y, o.points[k].x, o.points[k].y, o.points[k + 1].x, o.points[k + 1].y) <= tol) return o;
                        }
                    }
                }
            }
            return null;
        }

        // ===== 圖層 UI =====
        function objLabel(o) {
            if (o.type === 'stroke') return o.eraser ? '橡皮擦' : '筆劃';
            if (o.type === 'text') return '文字:' + String(o.text).slice(0, 8) + (String(o.text).length > 8 ? '…' : '');
            if (o.type === 'image') return '圖片';
            return o.type;
        }
        function objIcon(o) {
            if (o.type === 'stroke') return o.eraser ? 'fa-eraser' : 'fa-pencil';
            if (o.type === 'text') return 'fa-font';
            return 'fa-image';
        }
        function renderLayerList() {
            layerCount.textContent = `(${objects.length})`;
            layerList.innerHTML = '';
            if (!objects.length) {
                layerList.innerHTML = '<div class="text-center text-slate-400 py-6 text-[11px]">尚無圖層<br>畫筆或文字會自動新增</div>';
                return;
            }
            for (let i = objects.length - 1; i >= 0; i--) {
                const o = objects[i];
                const row = document.createElement('div');
                row.className = 'flex items-center gap-1.5 px-1.5 py-1 rounded border cursor-pointer ' + (o.id === selectedId ? 'bg-blue-100 border-blue-400' : 'bg-white border-slate-300 hover:bg-slate-50');
                const dotColor = o.type === 'stroke' ? o.color : o.type === 'text' ? o.color : '#00a2e8';
                row.innerHTML = `<span class="text-slate-400 w-5 text-center text-[10px]">${i + 1}</span>` +
                    `<i class="fa-solid ${objIcon(o)} text-slate-600 w-4 text-center"></i>` +
                    `<span class="w-3 h-3 rounded-full border border-slate-400 shrink-0" style="background:${dotColor}"></span>` +
                    `<span class="flex-1 truncate">${objLabel(o).replace(/</g, '&lt;')}</span>`;
                row.title = `圖層 ${i + 1}(由下往上)`;
                row.addEventListener('click', () => { selectedId = o.id; redrawAll(); });
                const del = document.createElement('button');
                del.className = 'text-slate-400 hover:text-rose-600 px-1';
                del.innerHTML = '<i class="fa-solid fa-xmark"></i>';
                del.title = '刪除此圖層';
                del.addEventListener('click', (e) => { e.stopPropagation(); selectedId = o.id; deleteSelected(); });
                row.appendChild(del);
                layerList.appendChild(row);
            }
        }
        function updateSelUI() {
            const sel = objects.find(o => o.id === selectedId);
            const has = !!sel;
            [delObjBtn, copyObjBtn, dupObjBtn, sysCopyBtn, forwardBtn, backwardBtn, frontBtn, backBtn].forEach(b => b.disabled = !has);
            pasteObjBtn.disabled = !clipboard;
            if (sel) {
                const idx = objects.findIndex(o => o.id === selectedId);
                selInfo.textContent = `已選:${objLabel(sel)}(層 ${idx + 1}/${objects.length})`;
                forwardBtn.disabled = idx >= objects.length - 1;
                frontBtn.disabled = idx >= objects.length - 1;
                backwardBtn.disabled = idx <= 0;
                backBtn.disabled = idx <= 0;
            } else {
                selInfo.textContent = clipboard ? '已複製 1 個物件,可貼上 (Ctrl+V)' : '';
            }
        }
        function cloneObj(o) {
            return JSON.parse(JSON.stringify(o));
        }
        // 剪貼簿(僅白板內部使用):存不含 id 的物件副本 + 貼上偏移次數
        let clipboard = null;
        let pasteCount = 0;
        const PASTE_DX = 20, PASTE_DY = 20;
        function copySelected() {
            const sel = objects.find(o => o.id === selectedId);
            if (!sel) return false;
            clipboard = cloneObj(sel);
            delete clipboard.id;
            pasteCount = 0;
            updateSelUI();
            return true;
        }
        function pasteClipboard() {
            if (!clipboard) return;
            pasteCount++;
            const c = cloneObj(clipboard);
            const dx = PASTE_DX * pasteCount, dy = PASTE_DY * pasteCount;
            if (c.type === 'stroke') {
                c.points = c.points.map(p => ({ x: p.x + dx, y: p.y + dy }));
            } else {
                c.x += dx; c.y += dy;
            }
            c.id = nextId++;
            objects.push(c);
            if (c.type === 'image') getImage(c.src);
            selectedId = c.id;
            saveState(); redrawAll();
        }
        function duplicateSelected() {
            if (!copySelected()) return;
            pasteClipboard();
        }
        // ===== 系統剪貼簿雙向互通 =====
        let sysPasteCount = 0;
        function flashStatus(msg) {
            // redrawAll 會重算 selInfo,延後覆寫以保留提示
            setTimeout(() => { selInfo.textContent = msg; }, 0);
        }
        function centerPos() {
            sysPasteCount++;
            const d = (sysPasteCount % 10) * 20;
            return {
                x: Math.round(canvas.width / 2 - 60 + d),
                y: Math.round(canvas.height / 2 - 20 + d)
            };
        }
        function addTextObject(text, pos) {
            const t = String(text || '');
            if (!t.trim()) return false;
            const p = pos || centerPos();
            objects.push({
                id: nextId++,
                type: 'text',
                x: Math.max(0, p.x),
                y: Math.max(0, p.y),
                text: t,
                color: currentColor,
                fontSize: currentFontSize
            });
            selectedId = nextId - 1;
            saveState(); redrawAll();
            return true;
        }
        function addImageDataURL(src) {
            const img = new Image();
            img.onload = function() {
                let w = img.width, h = img.height;
                if (w > canvas.width || h > canvas.height) {
                    const ratio = Math.min(canvas.width / w, canvas.height / h);
                    w *= ratio; h *= ratio;
                }
                const p = centerPos();
                imageCache[src] = img;
                objects.push({
                    id: nextId++,
                    type: 'image',
                    x: Math.max(0, Math.round(p.x - w / 2)),
                    y: Math.max(0, Math.round(p.y - h / 2)),
                    w: Math.round(w), h: Math.round(h),
                    src
                });
                selectedId = nextId - 1;
                saveState(); redrawAll();
                flashStatus('已從剪貼簿貼上圖片物件');
            };
            img.onerror = function() { flashStatus('剪貼簿圖片讀取失敗'); };
            img.src = src;
        }
        function renderObjectToBlob(o) {
            return new Promise((resolve) => {
                const b = getObjBounds(o);
                if (!b) { resolve(null); return; }
                const pad = 12;
                const off = document.createElement('canvas');
                off.width = Math.max(1, Math.ceil(b.w + pad * 2));
                off.height = Math.max(1, Math.ceil(b.h + pad * 2));
                const c = off.getContext('2d');
                c.fillStyle = '#ffffff';
                c.fillRect(0, 0, off.width, off.height);
                c.translate(-b.x + pad, -b.y + pad);
                if (o.type === 'image') {
                    const im = getImage(o.src);
                    const draw = () => {
                        c.drawImage(imageCache[o.src] || im, o.x, o.y, o.w, o.h);
                        off.toBlob(bl => resolve(bl), 'image/png');
                    };
                    if (im.complete && im.naturalWidth) draw();
                    else { im.onload = draw; im.onerror = () => resolve(null); }
                } else {
                    drawObject(c, o);
                    off.toBlob(bl => resolve(bl), 'image/png');
                }
            });
        }
        // 物件複製到系統剪貼簿:文字物件寫純文字(含 PNG 備援),筆劃/圖片轉 PNG
        async function copyToSystem() {
            const sel = objects.find(o => o.id === selectedId);
            if (!sel) return;
            copySelected(); // 同時保留內部備份
            try {
                if (!navigator.clipboard || !window.ClipboardItem) {
                    flashStatus('此環境不支援寫入系統剪貼簿(需 HTTPS/localhost),已改用內部複製');
                    return;
                }
                if (sel.type === 'text') {
                    const blobPng = await renderObjectToBlob(sel);
                    const itemData = { 'text/plain': new Blob([sel.text], { type: 'text/plain' }) };
                    if (blobPng) itemData['image/png'] = blobPng;
                    await navigator.clipboard.write([new ClipboardItem(itemData)]);
                } else {
                    const blob = await renderObjectToBlob(sel);
                    if (!blob) { flashStatus('轉 PNG 失敗,已改用內部複製'); return; }
                    await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
                }
                flashStatus('已複製到系統剪貼簿');
            } catch (err) {
                flashStatus('系統剪貼簿寫入被拒,已改用內部複製');
            }
            updateSelUI();
        }
        // 剪貼簿貼上成物件(按鈕觸發,經 Clipboard API,需使用者授權)
        async function pasteFromSystem() {
            try {
                if (navigator.clipboard && navigator.clipboard.read) {
                    let items = [];
                    try { items = await navigator.clipboard.read(); }
                    catch (readErr) { items = []; }
                    for (const item of items) {
                        for (const mime of item.types) {
                            if (mime.startsWith('image/')) {
                                const blob = await item.getType(mime);
                                const reader = new FileReader();
                                reader.onload = (ev) => addImageDataURL(ev.target.result);
                                reader.readAsDataURL(blob);
                                return; // 一次處理一張圖
                            }
                        }
                    }
                    for (const item of items) {
                        if (item.types.includes('text/plain')) {
                            const blob = await item.getType('text/plain');
                            const t = await blob.text();
                            if (addTextObject(t)) { flashStatus('已從剪貼簿貼上文字物件'); return; }
                        }
                    }
                }
                if (navigator.clipboard && navigator.clipboard.readText) {
                    try {
                        const t = await navigator.clipboard.readText();
                        if (t && t.trim()) {
                            if (addTextObject(t)) { flashStatus('已從剪貼簿貼上文字物件'); return; }
                        }
                    } catch (e) { /* 忽略,改用內部退回 */ }
                }
            } catch (err) {
                flashStatus('讀取系統剪貼簿失敗,改用內部貼上');
            }
            if (clipboard) { pasteClipboard(); }
            else { flashStatus('剪貼簿是空的(系統與內部皆無內容)'); }
        }
        function deleteSelected() {
            if (selectedId == null) return;
            objects = objects.filter(o => o.id !== selectedId);
            selectedId = null;
            saveState(); redrawAll();
        }
        function moveSelected(dir) {
            // dir: +1 上移, -1 下移, 'front' 置頂, 'back' 置底
            const idx = objects.findIndex(o => o.id === selectedId);
            if (idx < 0) return;
            const [o] = objects.splice(idx, 1);
            if (dir === 'front') objects.push(o);
            else if (dir === 'back') objects.unshift(o);
            else {
                const ni = Math.max(0, Math.min(objects.length, idx + dir));
                objects.splice(ni, 0, o);
            }
            saveState(); redrawAll();
        }
        delObjBtn.addEventListener('click', deleteSelected);
        copyObjBtn.addEventListener('click', copySelected);
        pasteObjBtn.addEventListener('click', pasteClipboard);
        dupObjBtn.addEventListener('click', duplicateSelected);
        sysCopyBtn.addEventListener('click', copyToSystem);
        sysPasteBtn.addEventListener('click', pasteFromSystem);
        // 系統貼上事件:圖片→圖片物件、文字→文字物件;無系統內容則退回內部貼上。
        // 與 Ctrl+V keydown 去重:keydown 只排程退回,真正貼上由 paste 事件統一處理。
        let lastPasteEventAt = 0;
        let pasteFallbackTimer = null;
        document.addEventListener('paste', (e) => {
            const tag = e.target && e.target.tagName;
            if (e.target === textInput || tag === 'INPUT' || tag === 'TEXTAREA') return; // 輸入框走原生貼上
            lastPasteEventAt = Date.now();
            if (pasteFallbackTimer) { clearTimeout(pasteFallbackTimer); pasteFallbackTimer = null; }
            const dt = e.clipboardData;
            if (dt) {
                const files = dt.files || [];
                for (const f of files) {
                    if (f.type && f.type.startsWith('image/')) {
                        e.preventDefault();
                        const reader = new FileReader();
                        reader.onload = (ev) => addImageDataURL(ev.target.result);
                        reader.readAsDataURL(f);
                        return;
                    }
                }
                const t = dt.getData('text/plain') || dt.getData('text');
                if (t && t.trim()) {
                    e.preventDefault();
                    if (addTextObject(t)) flashStatus('已從剪貼簿貼上文字物件');
                    return;
                }
            }
            // 無可用系統內容 → 退回內部貼上
            if (clipboard) { e.preventDefault(); pasteClipboard(); }
        });
        forwardBtn.addEventListener('click', () => moveSelected(1));
        backwardBtn.addEventListener('click', () => moveSelected(-1));
        frontBtn.addEventListener('click', () => moveSelected('front'));
        backBtn.addEventListener('click', () => moveSelected('back'));

        // 重新編排的色譜顏色順序
        const colors = [
            '#000000', '#ed1c24', '#ff7f27', '#ffc90e', '#fff200', '#22b14c', '#00a2e8', '#3f48cc', '#a349a4', '#7f7f7f',
            '#ffffff', '#ffaec9', '#ffc89d', '#fae7b5', '#b5e61d', '#99d9ea', '#7092be', '#c8bfe7', '#b97a57', '#c3c3c3'
        ];

        let colorButtons = [];

        colors.forEach((hex) => {
            const btn = document.createElement('button');
            btn.className = 'w-6 h-6 rounded border border-slate-400 shadow-sm transition hover:scale-110 relative';
            btn.style.backgroundColor = hex;

            btn.addEventListener('click', () => {
                currentColor = hex;
                updateColorSelection(btn);
            });

            paletteGrid.appendChild(btn);
            colorButtons.push({ btn, hex });
        });

        // 更新調色盤選取外框提示
        function updateColorSelection(selectedBtn) {
            colorButtons.forEach(item => {
                item.btn.classList.remove('ring-2', 'ring-blue-600', 'scale-110', 'z-10');
                item.btn.style.borderColor = '#94a3b8';
            });
            if (selectedBtn) {
                selectedBtn.classList.add('ring-2', 'ring-blue-600', 'scale-110', 'z-10');
                selectedBtn.style.borderColor = '#2563eb';
            }
            syncTextInputStyle();
        }

        // 預設選中第一個黑色
        if (colorButtons.length > 0) {
            updateColorSelection(colorButtons[0].btn);
        }

        customColorInput.addEventListener('input', (e) => {
            currentColor = e.target.value;
            updateColorSelection(null);
        });

        // 工具切換
        const allToolBtns = [pencilToolBtn, eraserToolBtn, textToolBtn, selectToolBtn];
        function setActiveTool(selectedBtn, toolName) {
            allToolBtns.forEach(btn => {
                btn.classList.remove('bg-blue-100', 'border-blue-400', 'text-blue-700');
                btn.classList.add('bg-white', 'border-slate-300', 'text-slate-700');
            });
            selectedBtn.classList.remove('bg-white', 'border-slate-300', 'text-slate-700');
            selectedBtn.classList.add('bg-blue-100', 'border-blue-400', 'text-blue-700');
            currentTool = toolName;
            if (toolName === 'eraser') canvas.style.cursor = 'cell';
            else if (toolName === 'text') canvas.style.cursor = 'text';
            else if (toolName === 'select') canvas.style.cursor = 'default';
            else canvas.style.cursor = 'crosshair';
            // 切換工具時若有未完成的文字,先提交
            if (toolName !== 'text') commitText();
        }

        pencilToolBtn.addEventListener('click', () => setActiveTool(pencilToolBtn, 'pencil'));
        eraserToolBtn.addEventListener('click', () => setActiveTool(eraserToolBtn, 'eraser'));
        textToolBtn.addEventListener('click', () => setActiveTool(textToolBtn, 'text'));
        selectToolBtn.addEventListener('click', () => setActiveTool(selectToolBtn, 'select'));
        // 快捷鍵
        document.addEventListener('keydown', (e) => {
            if (e.target === textInput) return;
            if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
            const mod = e.ctrlKey || e.metaKey;
            if (mod && (e.key === 'c' || e.key === 'C')) {
                if (selectedId != null) { e.preventDefault(); copySelected(); }
                return;
            }
            if (mod && (e.key === 'v' || e.key === 'V')) {
                // 交給 paste 事件優先處理系統內容;300ms 內無 paste 事件才用內部退回
                e.preventDefault();
                if (pasteFallbackTimer) clearTimeout(pasteFallbackTimer);
                const keyAt = Date.now();
                pasteFallbackTimer = setTimeout(() => {
                    pasteFallbackTimer = null;
                    if (lastPasteEventAt < keyAt && clipboard) pasteClipboard();
                    else if (lastPasteEventAt < keyAt && !clipboard) flashStatus('剪貼簿是空的(系統與內部皆無內容)');
                }, 300);
                return;
            }
            if (mod && (e.key === 'd' || e.key === 'D')) {
                if (selectedId != null) { e.preventDefault(); duplicateSelected(); }
                return;
            }
            if (e.key === 't' || e.key === 'T') setActiveTool(textToolBtn, 'text');
            else if (e.key === 'v' || e.key === 'V') setActiveTool(selectToolBtn, 'select');
            else if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId != null) { e.preventDefault(); deleteSelected(); }
            else if (e.key === 'Escape') {
                if (textOverlay.style.display === 'flex') cancelText();
                else { selectedId = null; redrawAll(); }
            }
        });

        brushSizeInput.addEventListener('input', (e) => {
            currentSize = parseInt(e.target.value);
            sizeText.textContent = currentSize;
        });

        fontSizeInput.addEventListener('input', (e) => {
            currentFontSize = parseInt(e.target.value);
            fontSizeText.textContent = currentFontSize;
            syncTextInputStyle();
        });

        // 取得畫布相對座標(含顯示縮放換算)
        function getMousePos(e) {
            const rect = canvas.getBoundingClientRect();
            const sx = canvas.width / rect.width;
            const sy = canvas.height / rect.height;
            return {
                x: Math.floor((e.clientX - rect.left) * sx),
                y: Math.floor((e.clientY - rect.top) * sy)
            };
        }

        // 繪圖 / 選取事件監聽
        canvas.addEventListener('mousedown', (e) => {
            const pos = getMousePos(e);
            // 文字工具:點一下就開啟 / 換位置輸入框
            if (currentTool === 'text') {
                if (textOverlay.style.display === 'flex' && textInput.value.trim() !== '') {
                    commitText();
                }
                openTextBox(pos.x, pos.y);
                return;
            }
            // 選取工具:命中測試 + 開始拖曳
            if (currentTool === 'select') {
                const hit = hitTest(pos.x, pos.y);
                selectedId = hit ? hit.id : null;
                if (hit) {
                    const snapshotPts = hit.type === 'stroke' ? hit.points.map(p => ({ ...p })) : null;
                    selectDrag = { sx: pos.x, sy: pos.y, ox: hit.x, oy: hit.y, pts: snapshotPts, moved: false, id: hit.id };
                }
                redrawAll();
                return;
            }
            // 筆刷 / 橡皮擦:開始新筆劃
            isDrawing = true;
            currentStroke = {
                points: [{ x: pos.x, y: pos.y }],
                color: currentTool === 'eraser' ? '#ffffff' : currentColor,
                size: currentSize
            };
            redrawAll();
        });

        canvas.addEventListener('mousemove', (e) => {
            const pos = getMousePos(e);
            coordInfo.textContent = `游標: ${pos.x}, ${pos.y}`;

            if (currentTool === 'select' && selectDrag) {
                const o = objects.find(v => v.id === selectDrag.id);
                if (o) {
                    const dx = pos.x - selectDrag.sx;
                    const dy = pos.y - selectDrag.sy;
                    if (Math.abs(dx) + Math.abs(dy) > 0) selectDrag.moved = true;
                    if (o.type === 'stroke') {
                        o.points = selectDrag.pts.map(p => ({ x: p.x + dx, y: p.y + dy }));
                    } else {
                        o.x = selectDrag.ox + dx;
                        o.y = selectDrag.oy + dy;
                    }
                    redrawAll();
                }
                return;
            }
            if (!isDrawing || !currentStroke) return;
            currentStroke.points.push({ x: pos.x, y: pos.y });
            redrawAll();
        });

        function endStroke() {
            if (isDrawing && currentStroke && currentStroke.points.length) {
                objects.push({
                    id: nextId++,
                    type: 'stroke',
                    points: currentStroke.points,
                    color: currentStroke.color,
                    size: currentStroke.size,
                    eraser: currentTool === 'eraser'
                });
                selectedId = nextId - 1;
                saveState();
            }
            isDrawing = false;
            currentStroke = null;
            redrawAll();
        }
        canvas.addEventListener('mouseup', () => {
            if (currentTool === 'select' && selectDrag) {
                if (selectDrag.moved) saveState();
                selectDrag = null;
                redrawAll();
                return;
            }
            endStroke();
        });

        canvas.addEventListener('mouseleave', () => {
            if (currentTool === 'select' && selectDrag) {
                if (selectDrag.moved) saveState();
                selectDrag = null;
                redrawAll();
                return;
            }
            if (isDrawing) endStroke();
        });

        // ===== 打字功能(提交即新增文字物件) =====
        function syncTextInputStyle() {
            textInput.style.color = currentColor;
            textInput.style.fontSize = currentFontSize + 'px';
            textInput.style.fontFamily = 'sans-serif';
        }

        // canvas 座標轉 wrapper 內定位(處理 canvas 縮放顯示的情況)
        function canvasToWrapper(x, y) {
            const rect = canvas.getBoundingClientRect();
            const wrapRect = canvasWrapper.getBoundingClientRect();
            const scaleX = rect.width / canvas.width;
            const scaleY = rect.height / canvas.height;
            return {
                left: (rect.left - wrapRect.left) + x * scaleX,
                top: (rect.top - wrapRect.top) + y * scaleY
            };
        }

        function openTextBox(x, y) {
            textPos = { x, y };
            const p = canvasToWrapper(x, y);
            textOverlay.style.display = 'flex';
            textOverlay.style.left = p.left + 'px';
            textOverlay.style.top = p.top + 'px';
            syncTextInputStyle();
            textInput.value = '';
            textInput.style.height = 'auto';
            setTimeout(() => textInput.focus(), 0);
        }

        function commitText() {
            if (textOverlay.style.display !== 'flex') return;
            const text = textInput.value;
            textOverlay.style.display = 'none';
            if (!text.trim()) { textInput.value = ''; return; }
            objects.push({
                id: nextId++,
                type: 'text',
                x: textPos.x,
                y: textPos.y,
                text: text,
                color: textInput.style.color || currentColor,
                fontSize: currentFontSize
            });
            selectedId = nextId - 1;
            textInput.value = '';
            saveState();
            redrawAll();
        }

        function cancelText() {
            textOverlay.style.display = 'none';
            textInput.value = '';
        }

        textOkBtn.addEventListener('click', (e) => { e.stopPropagation(); commitText(); });
        textCancelBtn.addEventListener('click', (e) => { e.stopPropagation(); cancelText(); });

        // 文字輸入框按鍵:Ctrl+Enter 完成、Esc 取消
        textInput.addEventListener('keydown', (e) => {
            e.stopPropagation();
            if (e.key === 'Escape') cancelText();
            if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commitText(); }
        });
        // 輸入時自動長高
        textInput.addEventListener('input', () => {
            textInput.style.height = 'auto';
            textInput.style.height = textInput.scrollHeight + 'px';
        });

        // 拖曳移動文字框(點藍色橫條拖曳)
        let dragState = null;
        textDragBar.addEventListener('mousedown', (e) => {
            e.preventDefault();
            e.stopPropagation();
            dragState = { sx: e.clientX, sy: e.clientY, lx: textOverlay.offsetLeft, ly: textOverlay.offsetTop };
            document.addEventListener('mousemove', onTextDrag);
            document.addEventListener('mouseup', endTextDrag, { once: true });
        });
        // 觸控拖曳
        textDragBar.addEventListener('touchstart', (e) => {
            const t = e.touches[0];
            dragState = { sx: t.clientX, sy: t.clientY, lx: textOverlay.offsetLeft, ly: textOverlay.offsetTop };
            document.addEventListener('touchmove', onTextTouchDrag, { passive: false });
            document.addEventListener('touchend', () => document.removeEventListener('touchmove', onTextTouchDrag), { once: true });
        }, { passive: true });
        function onTextDrag(e) {
            if (!dragState) return;
            const dx = e.clientX - dragState.sx;
            const dy = e.clientY - dragState.sy;
            textOverlay.style.left = (dragState.lx + dx) + 'px';
            textOverlay.style.top = (dragState.ly + dy) + 'px';
            syncTextPosFromOverlay();
        }
        function onTextTouchDrag(e) {
            e.preventDefault();
            const t = e.touches[0];
            const dx = t.clientX - dragState.sx;
            const dy = t.clientY - dragState.sy;
            textOverlay.style.left = (dragState.lx + dx) + 'px';
            textOverlay.style.top = (dragState.ly + dy) + 'px';
            syncTextPosFromOverlay();
        }
        function endTextDrag() {
            document.removeEventListener('mousemove', onTextDrag);
            dragState = null;
        }
        // 拖曳後回推 canvas 座標
        function syncTextPosFromOverlay() {
            const rect = canvas.getBoundingClientRect();
            const wrapRect = canvasWrapper.getBoundingClientRect();
            const scaleX = rect.width / canvas.width;
            const scaleY = rect.height / canvas.height;
            const ox = textOverlay.offsetLeft - (rect.left - wrapRect.left);
            const oy = textOverlay.offsetTop - (rect.top - wrapRect.top);
            textPos = { x: Math.round(ox / scaleX), y: Math.round(oy / scaleY) };
        }

        // 圖片上傳功能(改為新增圖片物件,可選取移動刪除)
        imageInput.addEventListener('change', (e) => {
            const file = e.target.files[0];
            if (!file) return;

            const reader = new FileReader();
            reader.onload = function(event) {
                const img = new Image();
                img.onload = function() {
                    let w = img.width;
                    let h = img.height;
                    if (w > canvas.width || h > canvas.height) {
                        const ratio = Math.min(canvas.width / w, canvas.height / h);
                        w *= ratio;
                        h *= ratio;
                    }
                    const x = Math.round((canvas.width - w) / 2);
                    const y = Math.round((canvas.height - h) / 2);
                    imageCache[event.target.result] = img;
                    objects.push({ id: nextId++, type: 'image', x, y, w: Math.round(w), h: Math.round(h), src: event.target.result });
                    selectedId = nextId - 1;
                    saveState();
                    redrawAll();
                }
                img.src = event.target.result;
            }
            reader.readAsDataURL(file);
            imageInput.value = '';
        });

        // 新增白板
        clearBtn.addEventListener('click', () => {
            cancelText();
            objects = [];
            selectedId = null;
            saveState();
            redrawAll();
        });

        // 復原與重做(物件快照)
        undoBtn.addEventListener('click', () => {
            if (historyStep > 0) {
                commitText();
                historyStep--;
                restore(history[historyStep]);
                redrawAll();
                updateHistoryButtons();
            }
        });

        redoBtn.addEventListener('click', () => {
            if (historyStep < history.length - 1) {
                historyStep++;
                restore(history[historyStep]);
                redrawAll();
                updateHistoryButtons();
            }
        });

        // 儲存圖片
        saveBtn.addEventListener('click', () => {
            const prevSel = selectedId;
            selectedId = null;
            redrawAll();
            const link = document.createElement('a');
            link.download = 'paint-drawing.png';
            link.href = canvas.toDataURL();
            link.click();
            selectedId = prevSel;
            redrawAll();
        });

        // 初始化
        initBoard();
    </script>
</body>
</html>
0
dscwferp
iT邦高手 1 級 ‧ 2026-09-07 22:14:53

刪除...

我要留言

立即登入留言